http://www.cs.mcgill.ca/~cs251/OldCourses/1997/topic3/

Ko govorimo o casovni ali prostorski zahtevnosti algoritmov,
ju ponavadi izrazimo kot funkciji v odvisnosti od velikosti 
konkretnega primera problema, ki ga algoritem resuje.  "Velikost"
primera je lahko izrazena z razlicnimi recmi -- pri problemih
na grafih obicajno gledamo stevilo tock in stevilo povezav v grafu.
Pri problemih na nizih je to mogoce dolzina vhodnega niza.
Pri problemih, kjer je nekaj treba narediti z neko mnozico ali
zaporedjem stvari, je to lahko stevilo elementov v mnozici ali
zaporedju.  Karkoli ze je, recimo tej velikosti problema kar "n".

Kakorkoli ze, znacilno za razmisljanja o zahtevnosti algoritmov
je, da nas zanima predvsem, kako se algoritem obnasa, ko velikost
problema (torej: vrednost n) narasca prek vseh meja -- zato pravimo, 
da nas zanima "asimptoticno" obnasanje algoritma.  

In ce je funkcija f(n), ki podaja zahtevnost algoritma, 
sestavljena iz vec clenov, bo tisti, ki narasca najhitreje 
(v odvisnosti od n-ja), scasoma (ko bo postajal n vse vecji
in vecji) tako prevladoval nad ostalimi, da bodo ostali v bistvu
cisto nepomembni.

Primer: f(n) = n^2 + 10n.
Pri n = 1 imamo 1 + 10,
pri n = 5 imamo 25 + 50,
pri n = 10 imamo 100 + 100,
pri n = 20 imamo 400 + 200,
pri n = 100 imamo 10000 + 1000,
pri n = 1000 imamo 10^6 + 10^4.
Skratka, vecji ko je n, bolj prevladuje n^2 nad 10^n.

Zato je v bistvu tisti +10n pri razmisljanju o zahtevnosti
tega algoritma cisto nepomemben (ce nas seveda zanimajo dovolj
veliki n).  Kajti ce bo treba pri n = 1000 na primer cakati 
10^6 casovnih enot in je to za nas sprejemljivo, potem tistih
dodatnih 10^4 casovnih enot pac ne bo nekaj, zaradi cesar bi se
radi vznemirjali.

Podobno velja pri primerjavi dveh algoritmov.  Imejmo recimo
se nek drug algoritem z zahtevnostjo g(n) = n^2 + 5 n log n + 20 n.
Pri n = 10 imamo 100 + 50 + 200,
pri n = 20 imamo 400 + 130.1 + 400,
pri n = 100 imamo 10000 + 1000 + 2000,
pri n = 1000 imamo 10^6 + 15000 + 20000.
Ce primerjamo to z zahtevnostjo prvega algoritma, vidimo, da
sta si pravzaprav zelo podobna -- ceprav je g(n) > f(n), je
razlika med njima zelo majhna v primerjavi z njunima vrednostma.
Ce smo pripravljeni potrpeti 1000000 casovnih enot, je pac
precej vseeno, ce moramo nato cakati se nadaljnjih 10000 (kot 
pri prvem algoritmu) ali pa nadaljnjih 35000 enot (kot pri drugem).

Imejmo pa zdaj se tretji algoritem z zahtevnostjo
h(n) = 3 n^2.  Pri n = 10 imamo 300, pri n = 20 imamo 1200,
pri n = 100 imamo 30000, pri n = 1000 imamo 3000000.
Ta algoritem torej vedno traja priblizno trikrat dlje
kot prejsnja dva, tisti drobiz pri ostalih dveh
(+10n ali +5n log n + 20n) pa je precej nepomemben.

In koncno, ce bi imeli se algoritem z zahtevnostjo
k(n) = 100 n log n, bi bilo to:
pri n = 10 enako 1000,
pri n = 20 enako pribl. 2602,
pri n = 100 enako 20000,
pri n = 1000 enako 300000.
Vidimo: ceprav je pri majhnih n-jih ta algoritem veliko 
zahtevnejsi od prejsnjih treh, je pri vecjih n ugodnejsi.
Pri npr. n = 10000 bi imeli f(n) = 10^10 + 10^6,
k(n) pa bi bil le 5*10^6, torej bi bil ze veliko veliko
boljsi od ostalih treh.  Skratka, ceprav je v formuli
100 n log n na zacetku precej velika konstanta (100),
scasoma vendarle prevlada dejstvo, da n log n narasca
pocasneje kot n^2.

Vidimo torej, da je pametno pri razmisljanju o zahtevnosti
algoritmov gledati le tisti clen v formuli, ki narasca 
najhitreje; nato pa, ce primerjamo vec algoritmov, pri
katerih ta clen narasca enako hitro, tudi konstanto,
s katero je ta clen pomnozen.  Zato je
h(n) = 3 n^2 nekaj slabsi od npr. f(n) = n^2 + 10n,
vendar je ta razlika med njima v bistvu malenkost v 
primerjavi s tem, koliko sta oba slabsa od k(n) = 100 n log n.

To dejstvo, da lahko vse clene razen najhitreje rastocega
v bistvu zanemarimo, je koristno tudi zato, ker v praksi
nimamo ze vnaprej podane neke funkcije f(n), ki bi opisovala
zahtevnost nasega algoritma -- do nje moramo sele priti,
ko imamo pac pred sabo svoj algoritem.  Delo si lahko 
olajsamo s tem, da podrobnosti, ki ne bi vplivale na
najhitreje narascajoci clen, zanemarimo.

(Vse nasteto je seveda upraviceno le, ce nas zanimajo
dovolj veliki n, da pride taksno asimptoticno obnasanje
funkcij do izraza.  Zgoraj smo na primer videli, da je
k(n) pri n = 1000 precej manjsi od h(n), vendar pa je
pri n = 10 precej vecji.  Vendar obicajno konstante, s
katerimi je pomnozen najvecji clen (in ki jih v bistvu
ne poznamo natancno), niso toliksne, kot je 100 v formuli
100 n log n, zato obicajno ni treba prav velikih n-jev,
da pride do izraza asimptoticno obnasanje funkcij.)

Ko smo doslej govorili o tem, da nek clen narasca najhitreje,
ostale pa zanemarimo, je bilo to mogoce slisati precej 
povrsno in ohlapno.  Zato so uvedli tudi posebno notacijo,
s katero lahko damo tem izrazom bolj natancno dolocen pomen.
Tako pravimo, da narasca f(n) kvecjemu tako hitro kot g(n),
kar zapisemo f(n) = O(g(n)), ce obstajata taki stevili
n0 in c, da je f(n) <= c*g(n) za vse n >= n0.
Kaj ta definicija pomeni?  Pri majhnih n dovolimo, da se
dogajajo kaksne cudne reci, ker se lahko se pozna vpliv
drugih clenov poleg tistega, ki bo asimptoticno narascal
najhitreje.  Zahtevamo pa, da od nekega n naprej (namrec
od n = n0 naprej) velja, da je g vecji od f, razen mogoce
za nek konstanten faktor c. 

Primer: n^2 + 10n = O(n^2).  Res, lahko bi vzeli n0 = 10 in c = 2
in bi videli, da za vse n >= 10 velja n^2 + 10n <= 2 * n^2.
Podobno je tudi n^2 = O(n^2 + 10 n), vendar je to ze malo
manj koristna ugotovitev.  Ravno tako je 
n^2 + 5 n log n + 20 n = O(n^2).  No, velja tudi
100 n log n = O(n^2), vendar je ta trditev v bistvu precej 
sibka, saj velja tudi 100 n log n = O(n log n).

Skratka, ce ste se gornje definicije s c-jem in n0 malo
prestrasili, se lahko zdaj pomirite, saj vidite, da ni treba
drugega, kot da clovek vzame najhitreje narascajoci clen,
odbije proc konstanto in dobi obcutek za to, v kaksnem
redu velikosti lezi opazovana funkcija.

(Tisti "O(...)" po mojem pride iz angleskega "order of", se 
pravi, da je f nekako po redu velikosti najvec toliksen kot g,
nisem pa cisto preprican.)

Obstajata se zapisa f(n) = Omega(g(n)), ki pomeni, da narasca
f vsaj tako hitro kot g (torej: obstajata neka n0 in c,
da je f(n) >= c*g(n) za vse n >= n0), in f(n) = Theta(g(n)), 
ki pomeni, da narasca tocno tako hitro kot g (torej: obstajajo
neki n0, c1 in c2, da je c1*g(n) <= f(n) <= c2*g(n) za vse
n >= n0), vendar ima clovek s tema dvema opraviti precej 
redkeje.  Obicajno nas pac zanima zgornja meja za to, kako
hitro narasca zahtevnost nekega algoritma, in v ta namen
uporabimo zapis O(g(n)).

Tule je nekaj preprostih funkcij, razvrscenih po tem,
kako hitro narascajo: 1; log(log n); log n; sqrt(n); 
n; n log n; n sqrt(n); n^2; n^3; ...; 2^n; 3^n; ...; n!; n^n.

(Zgolj mimogrede: n! (ki je definirana kot 1*2*3*...*(n-1)*n)
je priblizno enaka n^n * exp(-n) * sqrt(2*pi*n).  Temu pravijo
"Stirlingova formula".)

--

Za primer, kako lahko premislimo o zahtevnosti nekega algoritma,
si oglejmo tistega iz naloge o vsoti stevil v trikotniku, ki
smo jo resevali pred nekaj tedni.  Algoritem je bil tak:

vhod: tabela stevil A: array[1..n, 1..n] of Integer;
      (veljajo le A[i, j] za j <= i; i je stevilka
       vrstice, j pa polozaj znotraj vrstice).
izhod: najvecja vsota, ki jo lahko dobimo, ce zacnemo 
       v vrhu trikotnika in gremo dol do dna.
begin:
1.  for i := n-1 downto 1 do
2.    for j := 1 do i do 
3.      A[i, j] := A[i, j] + max(A[i+1, j], A[i+1, j+1]);
4.  vrni A[1, 1];
end;

Ce bi bil clovek pedanten, bi se lahko zdaj spravil stet razne
operacije, ki jih mora nas program izvesti.  Ko je i enak n-1,
se izvede notranja zanka (for j) n-1-krat; ko je i enak n-2,
se izvede n-2-krat in tako naprej; skupaj torej
1+2+3+...+(n-2)+(n-1) = n*(n-1)/2-krat.  Toliko imamo torej
sestevanj celih stevil (v vrstici 3); tolikokrat moramo
izracunati maksimum dveh stevil (kar v bistvu pomeni eno
primerjavo); tolikokrat mora navsezadnje prevajalnik
povecati stevec j.  Ce bi steli dostope do tabele A, bi
jih bilo verjetno nekajkrat vec, saj mora vrstica 3 prebrati 
stevila A[i+1, j], A[i+1, j+1] in A[i, j] ter nato vpisati
novo vrednost v A[i, j].  Potem bi rekli, da je casovna
zahtevnost nasega programa enaka
    CasEnegaSestevanja * n*(n-1)/2 +
  + CasDostopaDoTabeleA * NekaKonstanta * n*(n-1)/2 +
  + CasPovecevanjaStevcaJ * n*(n-1)/2 +
  + CasPovecevanjaStevcaI * n + 
  + CasPotrebenDaVrnemoA11vVrstici4 + mogoce se kaj.
Vse to je seveda grozno okorno, komplicirano in tudi
neugodno, saj tezko pametno ocenimo trajanje raznih osnovnih
operacij (oz. razmerja med trajanji razlicnih operacij), pa
tudi ne vemo, kaksne nepredvidene malenkosti se lahko se 
pojavijo (npr. pri for zankah je treba izvajati kaksne 
primerjave, da preverimo, kdaj smo prisli do konca).
Na sreco je vse to tudi cisto nepotrebno.  Vidimo lahko,
da med vsemi temi cleni najhitreje narascajo cleni 
oblike n*(n-1)/2, kar pa lahko zapisemo kot 
0.5 * n^2 - 0.5 * n.  Na koncu torej lahko zapisemo, da je
casovna zahtevnost gornjega algoritma enaka O(n^2).
Pa naj se kdo rece, da asimptoticni zapis ni koristen. :)

(Ce bi bil clovek manj pedanten, bi lahko kar takoj, ko
pogleda na algoritem, videl, da ima dve gnezdeni zanki,
ki gresta v najslabsem primeru do n, in bi takoj rekel, da
je casovna zahtevnost tu O(n^2).  Vendar je lahko vcasih
tak povrsen pogled pretirano pesimisticen (glej spodaj).  
Tudi v gornjem primeru nam je podrobnejsi razmislek vendarle
povedal tudi kaj koristnega, npr. to, da je vrstico 3 
vendarle treba izvesti v bistvu bolj 0.5 n^2-krat kot 
pa n^2-krat.)

(Ce bi bil clovek res ekstremno pedanten, bi se ze zdavnaj
pritozil, ker smo rekli, da je sestevanje operacija, ki se
jo da izvesti v konstantnem casu.  To je v bistvu res le,
ce so stevila, s katerimi bomo morali racunati, navzgor
omejena.  Drugace pa je menda jasno, da je treba za sestevanje
1000-bitnih stevil dalj casa kot za sestevanje 32-bitnih,
za sestevanje 100000-bitnih pa je treba se dalj casa.  In
ce dovolimo, da gre n prek vseh meja, bi lahko te vsote 
scasoma mirno postale tudi 100000-bitne.  Vendar se s taksnimi
pedantnostmi seveda ne bi ukvarjal noben normalen clovek.
Lahko pa bi takega pedantneza pomirili tudi z izjavo, da
gornji algoritem zahteva O(n^2) sestevanj in nekaterih drugih
osnovnih operacij, nic pa ne bi rekli o tem, koliko casa
traja posamezna od teh osnovnih operacij.)

--

Kot se en primer analize casovne zahtevnosti algoritma si 
oglejmo topolosko urejanje, ki ga tudi ze poznamo.  Vhod je nek
graf z mnozico tock V in mnozico povezav E.  Algoritem pa je:

 1. za vsako tocko u iz V: inDeg[u] := 0;
 2. za vsako povezavo (u->v) iz E: inDeg[v] := inDeg[v] + 1;
 3. q := [prazna vrsta];
 4. za vsako tocko u iz V:
 5.   if inDeg[u] = 0 then 
 6.     dodaj u na konec vrste q;
 7. dokler ni q prazna:
 8.   vzemi prvi element, recimo u, iz vrste q;
 9.   izpisi u;
10.   za vsakega u-jevega naslednika, recimo v:
11.     inDeg[v] := inDeg[v]-1;
12.     if inDeg[v] = 0 then
13.       dodaj v na konec vrste q;

Ni tezko videti, da nam bo pozrla vrstica 1 priblizno O(|V|)
casa, vrstica 2 pa O(|E|).  Vrstica 3 le konstantno mnogo.
Zanka v vrsticah 4..6 spet O(|V|) casa, ker mora iti skozi
vseh |V| tock in ima z vsako tudi v najslabsem primeru le
konstantno veliko dela.  

No, kaj pa glavna zanka, tista od vrstice 7 naprej?  Tu je
koristen naslednji razmislek: vsako tocko dodamo v vrsto le
enkrat, namrec takrat, ko njena vhodna stopnja pade na 0;
torej lahko tudi vsako le enkrat vzamemo iz vrste; mi pa v
vsaki ponovitvi glavne zanke vzamemo iz vrste po eno tocko;
torej se glavna zanka ponovi najvec tolikokrat, kolikor je
tock (v resnici se ponovi natanko tolikokrat, razen ce ne
naletimo v grafu na kaksne cikle).  Vrstici 8 in 9 se torej
izvedeta O(|V|)-krat, tolikorkrat kot sama zanka 7..13.
Kaj pa notranja zanka 10..13?  Ocitno imamo v vrsticah 11..13
pri vsaki ponovitvi te zanke konstantno veliko dela. 
Kak pesimist bi rekel: ta zanka gre po vseh u-jevih naslednikih,
to pa so lahko v najslabsem primeru kar vse ostale tocke,
torej se lahko zanka 10..13 pri vsaki ponovitvi zunanje
zanke izvede O(|V|)-krat in se bodo zato vrstice 11..13
izvedle skupno O(|V|^2)-krat.  To je sicer res, vendar je
lahko bolj pesimisticno, kot je potrebno.  Zanka 10..13
se izvede vsega skupaj tolikokrat, kolikorkrat se v nasem
grafu zgodi, da je neka tocka v naslednica neke tocke u.
To pa se zgodi natanko tolikokrat, kolikor je v grafu
povezav.  Torej se vrstice 11..13 izvedejo vsega skupaj le
O(|E|)-krat in nam tudi pozrejo le O(|E|) casa.

Ce zdaj vse skupaj sestejemo, vidimo, da nam pozre ta
algoritem O(|V| + |E|) casa.  Ocena O(|V|^2), ki smo jo
videli sredi prejsnjega odstavka, je sicer tudi pravilna,
vendar pesimisticna, saj je |E| <= |V|^2 (cetudi bi vsaka
tocka kazala na vse ostale, bi imeli se vedno le 
|V| * (|V|-1) povezav).

Mimogrede, pri zgornjem algoritmu smo predpostavili, da nam
ne povzrocajo zanke, kot je "za vsakega u-jevega naslednika"
v vrstici 10, nobenega posebnega overheada -- predpostavili
smo, da se zanka 10..13 izvede vsakic le tolikokrat, kolikor
naslednikov ima u.  To seveda lahko drzi, ce imamo graf 
predstavljen s seznami sosedov, ce pa bi ga imeli predstavljenega
z matriko sosednosti, bi ta zanka v bistvu vedno morala
iti po vseh tockah in pri vsaki preveriti (pogledati v matriko),
ce je ta slucajno naslednik u-ja.  Vrstice 11..13 bi se
sicer se vedno izvedle le pri vsakem nasledniku, vendar bi
nam zdaj ze preverjanje, ali je neka tocka naslednik u-ja
ali ne, pozrlo vsega skupaj O(|V|^2) casa in bi s tem tudi
zahtevnost celega algoritma narasla na O(|V|^2).
Skratka, jasno je, da je casovna (in tudi prostorska)
odvisnost algoritma mocno odvisna od tega, kako ima predstavljene
in shranjene podatke, s katerimi dela.

--

To tematiko sem opisal predvsem zato, ker se mi zdi, da je
zamisel o asimptoticni rasti funkcij, skupaj z notacijo
tipa O(nekaj), precej koristna zadeva, ko clovek razmislja
ali govori o zahtevnosti algoritmov.  Pravzaprav mi je zal,
da je nisem omenil ze prej, ker bi prisla cisto prav tudi
pri dosedanjih mailih o raznih vrstah algoritmov.  Skratka,
ce boste kje naleteli na trditev, da je neka stvar 
O(nekaj), to pac pomeni, da narasca v najslabsem primeru
kvecjemu nekako tako hitro kot tisti nekaj.

--

Se ena mogoce koristna stvar pri tej tematiki je, da poudarja
dejstvo, da je dober _algoritem_ ponavadi veliko dragocenejsi
kot dobra _implementacija_.  Recimo, da imam slabo implementacijo 
slabega algoritma, pa zahteva n^3 operacij pri problemu velikosti n;
potem pa implementacijo izboljsam, da zahteva le se 0.01 n^3 
operacij (pa se preklemano tezko zgodi, da bi lahko z masiranjem 
kode kak program pospesili stokratno); toda, ce bi namesto tega 
nasel boljsi algoritem, ki zahteva n^2 operacij, bi bilo to ze 
pri ne tako zelo velikih n-jih precej bolje.  Isti nasvet 
lahko pride prav tudi pri raznih tekmovanjih: pametno je najprej 
razmisliti o cim boljsem algoritmu, sele nato pa o tem, kako bi 
izbrani algoritem dobro in ucinkovito implementirali.  Ce si
izberemo neucinkovit algoritem, nam obicajno tudi dobra 
implementacija ne pomaga in bo nas program vseeno rusil 
postavljene casovne omejitve, razen mogoce pri kaksnih manjsih
testnih primerih.  To na primer pomeni, da je kaksna besna
rekurzija, ki bo zrla eksponentno mnogo casa, skoraj gotovo
slaba resitev, s katero se je smiselno ukvarjati le, ce se 
boljsega res ne domislimo ali pa ce smo res prepricani, da 
boljsega algoritma sploh ni.

--

LP, Janez

